Skip to content

feat(insights): subscribers tab (NPPD-1616)#217

Merged
kmwilkerson merged 58 commits into
mainfrom
nppd-1616-insights-tab-6-subscribers
Jun 9, 2026
Merged

feat(insights): subscribers tab (NPPD-1616)#217
kmwilkerson merged 58 commits into
mainfrom
nppd-1616-insights-tab-6-subscribers

Conversation

@kmwilkerson

@kmwilkerson kmwilkerson commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Builds the fully-functional Subscribers tab inside the Insights wizard. This is the first Insights tab that ships with real publisher data, sourced entirely from local WooCommerce (no BigQuery dependency).

Stacks on the chrome PR (#211, NPPD-1602). Both PRs need to merge for Subscribers to be visible — chrome alone is empty tabs, Subscribers alone won't load without the chrome scaffold.

image

What's in this PR

Storage abstraction layer

  • Storage_Interface defining the per-metric query contract
  • HPOS_Storage and Legacy_Storage implementations dispatching by woocommerce_custom_orders_table_enabled
  • Storage_Detector caching the backend detection result (24h TTL)
  • Both backends verified end-to-end against test data

Donation product classifier

  • Donation_Product_Classifier wrapping the canonical \Newspack\Donations::is_donation_product() and is_donation_order() methods
  • Caches the donation product ID set (1h TTL) for use in NOT IN filters across subscription queries
  • Combines all three canonical detection paths (legacy parent/child, v6.41.0 manual flag, variation inheritance)

Subscribers metric and REST endpoint

  • Subscribers_Metric orchestrator with per-method WP transient caching (30min / 60min TTL for v1; will migrate to NPPD-1605 cache table when that lands)
  • Single REST endpoint GET /newspack-insights/v1/subscribers returning the full payload (classification metadata, snapshot, current window, optional comparison window). Caching is per-metric inside the orchestrator so a comparison-mode request reuses the same per-method cache entries.

React UI

  • SubscribersTab with four sections: at-a-glance scorecards, time-windowed metrics, subscriber tenure, performance by product
  • Scorecards regrouped by temporal scope: current-state metrics (Active, MRR, ARR, Upcoming Renewals) vs window-scoped metrics (New, Churned, Gross, Net, Refund Rate, Failed Payment Recovery)
  • Window-scoped section header is dynamic — reflects the active date range ("In the last 30 days", "This month", "From Sep 5 to Oct 5", etc.)
  • All scorecards share consistent typography (44px / 500 hero, font lockdown to prevent admin-chrome font flips) and a brand-color top accent per design spec
  • Subscriber tenure card shows median + p25 + p75 callouts plus an interpretive sentence ("Half of your subscribers have been here longer than N days. A quarter have been here longer than N days."); the histogram was cut after design review since it duplicated the same information
  • Performance by product table with explanatory caption clarifying "subscriptions, not unique customers"

Comparison mode

  • All window-scoped scorecards render deltas when comparison mode is toggled on
  • lowerIsBetter semantics for churn count and refund rate (drops render green, increases render red)
  • Comparison ignored for current-state metrics that don't have meaningful prior values

How to test

  1. Set NEWSPACK_INSIGHTS_ENABLED constant in wp-config.php
  2. Navigate to wp-admin/admin.php?page=newspack-insights
  3. Click into the Subscribers tab
  4. Verify:
    • All 10 scorecards render with real data (4 at-a-glance + 6 windowed)
    • Section headers correctly reflect "Subscribers at a glance" vs the dynamic windowed group
    • Date range picker changes affect only window-scoped scorecards (not current-state ones)
    • Comparison toggle on: deltas appear with correct colors (churn drop = green, refund rate up = red)
    • Tenure card shows median/p25/p75 + interpretive sentence
    • Performance by product table shows real subscription counts with explanatory caption
    • Hard refresh restores state from URL
  5. Verify storage abstraction by testing on both HPOS-enabled and HPOS-disabled environments

kmwilkerson and others added 30 commits June 3, 2026 16:09
Top-level Insights_Wizard extending Wizard with slug newspack-insights,
parent_menu newspack-dashboard (nests under the top-level Newspack admin
menu — matches Setup wizard precedent), capability manage_options. The
React view is registered separately in src/wizards/index.tsx under the
slug key.

enqueue_scripts_and_styles() localizes a 'newspackInsights' boot config:
tab visibility (stubbed all-on pending NPPD-1598 BQ wrapper + Woo queries
for real feature detection), default date range (last 30 days),
default comparison mode (off), site timezone, settings URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
One section class per tab:
- Insights_Section_Audience      (Audience)
- Insights_Section_Engagement    (Engagement)
- Insights_Section_Conversion    (Conversion Journey)
- Insights_Section_Gates         (Gates)
- Insights_Section_Prompts       (Prompts)
- Insights_Section_Subscribers   (Subscribers)
- Insights_Section_Donors        (Donors)
- Insights_Section_Advertising   (Advertising)

Each is a plain class (NOT extending Wizard_Section, NOT registered via
the wizard's sections array) with:
- SECTION_NAME constant matching the React tab label
- static init() that calls self::register_hooks()
- empty register_hooks() — placeholder for future per-tab REST
  endpoint registration as each tab's data layer lands (NPPD-1604,
  1607, 1608, 1609, 1616, 1617, 1618, 1624)
- Doc block describing tab scope and visibility constraints

This is a new convention introduced for Insights: tab routing happens
on the React side via URL query persistence, so PHP doesn't register
8 separate wizards (like Audience does) — these classes exist as the
documented hook point for future REST work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire up the 9 new PHP files:

- includes/class-newspack.php: include_once for the Insights_Wizard
  class file plus all 8 section stub files (alphabetical within the
  insights/ subdir grouping)

- includes/class-wizards.php: add 'insights' => new Insights_Wizard()
  to the $wizards array, positioned between audience-integrations and
  listings (matches the visual order in admin)

- includes/class-wizards.php: call ::init() on all 8 section classes
  at the tail of init_wizards() so their (currently empty) register_hooks()
  runs during the 'init' action — placeholder hookpoint for future
  per-tab REST work

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…PPD-1602)

useDateRange (state/useDateRange.ts):
- DateRangePreset type with 6 presets: last-7, last-30, last-30 (default),
  last-90, this-month, last-month, custom
- Hydrates initial state from URL query params (range, start, end) with
  fallback to boot config default
- Persists changes via history.replaceState (no history pollution)
- Exports computeRangeForPreset() pure helper for testing
- Validates URL inputs against /^\d{4}-\d{2}-\d{2}$/ before trusting

useComparisonMode (state/useComparisonMode.ts):
- Boolean state for "compare to previous period" toggle
- Hydrates from ?compare=1 in URL; default off
- Computes previous-period range as same-length-back (immediately
  preceding current window, no overlap) via computePreviousRange() pure
  helper, memoized against current range
- previousRange is null when comparison disabled or current range
  is malformed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…le, LastUpdated (NPPD-1602)

DateRangePicker (components/DateRangePicker.tsx):
- Stateless: caller wires to useDateRange
- Native <select> for preset choice (6 options pulled from
  DATE_RANGE_PRESETS)
- Custom mode reveals two type="date" inputs separated by an arrow

ComparisonToggle (components/ComparisonToggle.tsx):
- Stateless: caller wires to useComparisonMode
- Single checkbox: "Compare to previous period"

LastUpdated (components/LastUpdated.tsx):
- Takes an ISO 8601 timestamp prop (or null if not yet known)
- Renders relative time ("Updated 12 minutes ago", "Updated 3 hours ago",
  "Updated 2 days ago", "Updated just now")
- Title attribute holds the absolute timestamp for tooltip on hover
- Renders nothing if timestamp is null or unparseable — safe for boot
  state before first cache hit

Per spec at ~/Sites/insights-docs/component-design-spec.md. All three
intentionally lean on native HTML inputs (no @wordpress/components
dependencies) so the chrome can render before WP-data hydration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TabNavigation (components/TabNavigation.tsx):
- Exports TabKey type and ALL_TABS list (single source of truth for tab
  identity + display label)
- Stateless: active state via prop, click via callback
- ARIA-correct: role="tablist" on nav, role="tab" + aria-selected +
  aria-controls per button
- Conditional visibility per TabVisibility prop (record per TabKey).
  Hidden tabs are filtered out entirely (not rendered with display:none).

TabContent (components/TabContent.tsx):
- Lazy-loads each of the 8 tab components via React.lazy
- Suspense boundary wraps the render with a simple "Loading…" fallback
- Switch-based dispatch on activeTab (one place to thread the lazy
  imports)
- ARIA-correct: role="tabpanel", id/aria-labelledby paired with the
  TabNavigation button IDs
- Passes activeTab, range, previousRange down to each tab. Tabs receive
  prop-shape they need for future data fetching even though current
  stubs ignore them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ders (NPPD-1602)

Eight files in src/wizards/insights/tabs/:
- AudienceTab.tsx       (real content: NPPD-1604)
- EngagementTab.tsx     (NPPD-1607)
- ConversionTab.tsx     (NPPD-1608)
- GatesTab.tsx          (NPPD-1609)
- PromptsTab.tsx        (NPPD-1616)
- SubscribersTab.tsx    (NPPD-1617)
- DonorsTab.tsx         (NPPD-1618)
- AdvertisingTab.tsx    (NPPD-1624)

Each renders a centered tab name + "Coming soon" using shared
.newspack-insights__tab-stub styles defined alongside the wizard
chrome. Each is its own file so TabContent's React.lazy() can
code-split per tab; this issue's bundle just gets 8 trivial chunks
that future PRs replace with the real per-tab data flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ties the chrome together:

- Owns active tab state with URL persistence (?tab=...). Initial tab
  hydrates from URL with validation against visibility config; if the
  URL-named tab is hidden for this publisher, falls back to the first
  visible tab (handles the edge where someone bookmarks an Advertising
  tab and the publisher hasn't enabled GAM yet).
- Threads useDateRange and useComparisonMode (both URL-persistent).
- Renders the page in three slots: header (title + date picker +
  comparison toggle + last-updated, all in one row), tab navigation,
  and lazy tab content.
- previousRange from useComparisonMode flows through TabContent to
  tabs so future per-tab data fetching can request both windows.

InsightsBootConfig type documents the wp_localize_script payload shape
in one place; the PHP wizard's get_boot_config() and this type should
stay in sync as the real feature-detection logic lands (NPPD-1598+).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…PD-1602)

src/wizards/insights/index.tsx:
- Reads window.newspackInsights (populated by PHP via wp_localize_script)
- Falls back to a hardcoded all-tabs-on / last-30-days config if missing
  (defensive; the PHP path should always populate, but the React module
  can render in isolation for development without a runtime crash)
- Mounts <InsightsWizard config={...} /> as the default export

src/wizards/index.tsx:
- Adds 'newspack-insights' key to the lazy-loaded components map
- Code-split into its own chunk via /* webpackChunkName: "insights-wizard" */
  so the Insights bundle stays out of the shared newspack-wizards chunk
  and only loads when ?page=newspack-insights

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Page-level styles for the Insights wizard chrome only — per-component
data viz styles live in packages/components/src/ and don't belong here.

- Page: 1200px max-width, 24px horizontal padding, gray-900 base
- Header: flex row (title left, picker/toggle/timestamp right), wrap to
  multiple rows on narrow widths
- Title: 28px / 600 (matches the data viz demo gallery convention)
- DateRangePicker: native <select> + date inputs styled to match WP
  admin form controls (gray-300 border, 4px radius, focus-visible
  outline in admin theme color)
- ComparisonToggle: inline checkbox with 13px label
- Tab nav: horizontal bar with bottom border. Active tab gets the
  admin theme color + bottom-border underline. Tabs wrap to multiple
  rows on narrow widths.
- Tab content area: 320px min-height so the page doesn't collapse on
  Suspense fallback
- Tab stub: centered title + "Coming soon" with 320px min-height

Per spec at ~/Sites/insights-docs/component-design-spec.md type scale
and color usage rules. wp-colors for all neutrals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ocks

The 8 PHP section stubs and 8 corresponding React tab stubs were
documented with an inverted Linear issue map (original prompt's draft
numbering, not the actual issue assignments). Confirmed correct
mapping is:

  Insights_Section_Audience       -> NPPD-1608
  Insights_Section_Engagement     -> NPPD-1624
  Insights_Section_Conversion     -> NPPD-1609
  Insights_Section_Gates          -> NPPD-1604
  Insights_Section_Prompts        -> NPPD-1607
  Insights_Section_Subscribers    -> NPPD-1616
  Insights_Section_Donors         -> NPPD-1617
  Insights_Section_Advertising    -> NPPD-1618

Doc-block-only change across 16 files (8 PHP + 8 TSX). No behavior
change. The TSX tab stub doc blocks had the same misalignment as the
PHP section docs, so they're updated in the same commit for
consistency — the user's directive scoped to "section stub doc
blocks" but leaving the tab stubs inconsistent would have introduced
a different drift across the same surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
"Last N days" presets were subtracting N days from today, producing an
(N+1)-day inclusive window — e.g. on Jun 3, last-30 returned May 4 → Jun 3
inclusive, which is 31 days. Subtract (N-1) instead so the window is
exactly N days end-to-end (May 5 → Jun 3 = 30 days inclusive).

Fixes Copilot review on PR #210 in three places:
- includes/wizards/insights/class-insights-wizard.php: PHP boot config
  default range (also added comment confirming current_datetime() returns
  DateTimeImmutable so modify() is non-mutating — Copilot flagged this
  as a mutation risk but WP docs and runtime behavior say otherwise; the
  real bug was the off-by-one, not mutation)
- src/wizards/insights/state/useDateRange.ts: computeRangeForPreset
  for last-7 / last-30 / last-90
- src/wizards/insights/index.tsx: FALLBACK_CONFIG default range

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n __()

Fixes Copilot review on PR #211. Previously the user-facing strings in
TabNavigation (8 tab labels + the nav aria-label) and useDateRange (6
preset labels) were hardcoded English. Wrapped each in __() with the
'newspack-plugin' text domain so wp-scripts string extraction picks
them up for the .pot file and wp-admin renders them in the active
locale.

Module-load-time __() calls are fine — wp-i18n does runtime translation
lookup against bundled translations and locale doesn't change within
a session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes two Copilot review points on PR #211:

1. settingsUrl was in InsightsBootConfig but unused. Now rendered as a
   "Settings" link in the chrome header-right group (always visible when
   the URL is present, even in the empty-state case where the rest of
   the header tools hide — settings should remain reachable for
   configuration).

2. When visibility config had zero true tabs, the chrome rendered a
   blank tab area (TabNavigation empty, TabContent rendering audience
   panel anyway because readInitialTab forced 'audience'). Now:
   - readInitialTab returns null when no tabs are visible
   - InsightsWizard renders a dedicated empty state ("No insights
     sections available") with a brief explanation directing the user
     to Settings
   - DateRangePicker / ComparisonToggle / LastUpdated hide too — they're
     not actionable without any tab to display

Empty-state SCSS follows the spec's empty-state vocabulary (centered,
generous padding, 22px title + 14px gray-700 message, max-width 480px
for readability).

The timezone field stays in InsightsBootConfig for future date-formatting
use (e.g., LastUpdated's absolute tooltip; per-tab data renderers when
real data arrives); not yet consumed, but the PHP side already populates
it so removing now means adding back later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…comment)

CI on PR #211 surfaced lint failures the local build alone didn't catch.
This commit makes the lint checks pass:

PHP (phpcs / phpcbf):
- Aligned associative array double-arrows in get_boot_config()
- Converted the block-comment annotation above 'tabs' to // comments to
  satisfy WordPress.Files.SpaceBeforeBlockComment (a leading blank line
  inside an array literal reads worse than just using line comments)

JS / TS (prettier + jsx-a11y):
- Prettier-formatted all insights tab stubs and components to satisfy
  the project's prettier config (line-break style on inline JSX)
- Added htmlFor/id pairs to <label> + control associations in
  ComparisonToggle and DateRangePicker for jsx-a11y/label-has-associated-control
- Switched TabNavigation's root from <nav role="tablist"> to
  <div role="tablist"> for jsx-a11y/no-noninteractive-element-to-interactive-role
  (nav is non-interactive; tablist is interactive)

Net behavior unchanged; doc blocks intact; visible markup identical
modulo the nav→div swap which is a11y-correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…constant

Gates Insights wizard registration, asset enqueueing, and section
stub initialization behind NEWSPACK_INSIGHTS_ENABLED. Default: off.

Allows merging subsequent Insights PRs incrementally to main without
exposing the in-progress feature to publishers. The flag is removed
once Insights is ready for general release.

Pattern follows Private_Tags::is_enabled() (includes/tags/class-private-tags.php).

Gating points:

- Insights_Wizard::__construct() bails before parent::__construct() runs,
  so no admin_menu / admin_enqueue_scripts / admin_body_class hooks are
  registered. The object exists in the Wizards $wizards array but is a
  no-op. wp-admin: no menu item, no asset enqueue, the page returns
  WP's "do not have sufficient permissions" since the slug isn't
  registered.

- All 8 section stub init() methods (Audience, Engagement, Conversion,
  Gates, Prompts, Subscribers, Donors, Advertising) bail before calling
  register_hooks(). They're no-ops today; the gate belongs here so when
  per-tab REST registration lands in subsequent PRs it inherits the
  gate for free.

Verified at runtime: with NEWSPACK_INSIGHTS_ENABLED undefined,
is_enabled() returns false and the admin_menu add_page hook is not
attached to the wizard instance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
13 typed methods, one per Tab 6 metric, fixing the PHP boundary that
both backend implementations (HPOS, legacy CPT) will satisfy:

- get_active_non_donation_subscribers
- get_new_subscribers_in_window
- get_churned_subscribers_in_window
- get_mrr / get_arr
- get_subscription_revenue_gross / _net
- get_subscription_refund_rate
- get_subscription_tenure_distribution
- get_upcoming_renewals_30d
- get_failed_payment_retry_rate
- get_performance_by_product
- get_cancellation_reasons

Donation product IDs are injected at construction (not threaded
through each method signature) so the per-method contracts stay
clean — see Donation_Product_Classifier::get_donation_product_ids().

Namespace: \Newspack\Insights\Storage_Interface. Sub-namespace
matches the prompt's notation (Insights\Storage_Interface) and the
prior section stubs in the chrome's \Newspack namespace remain
unaffected.

SQL bodies live in:
- ~/Sites/insights-docs/formulas/tab-6-subscribers.md
- ~/Sites/insights-docs/formulas/subscription-donation-schema.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…PPD-1616)

Reads the woocommerce_custom_orders_table_enabled option, returns
either Storage_Detector::BACKEND_HPOS ('hpos') or
Storage_Detector::BACKEND_LEGACY ('legacy'). Caches the result in a
24h transient since HPOS migration is a one-way event and the option
rarely flips.

Two entry points:
- detect(): cached read, recomputes only on cache miss
- force_refresh(): bypass + refresh cache, returns fresh value

force_refresh() is the hook point for NPPD-1605's eventual cache
invalidation layer and for the HPOS migration window where a single
admin session might toggle the option mid-flight.

The data_sync_enabled flag mentioned in the schema doc isn't relevant
here — that affects which backend's reads are *trustworthy* but not
which is *active*. The active backend is solely determined by the
custom_orders_table_enabled option.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements Storage_Interface against the HPOS tables ({prefix}wc_orders,
{prefix}wc_orders_meta, {prefix}wc_order_product_lookup). All 13
methods, SQL adapted from ~/Sites/insights-docs/formulas/tab-6-
subscribers.md.

Compat notes:

- WITH ... AS () CTEs in the formula doc are rewritten as inline
  subqueries; MySQL 5.7 (which some Newspack-hosted publishers run)
  does not support CTEs.
- Donation product IDs are injected at construction. Empty input
  coerces to (0) via id_list() so NOT IN clauses stay syntactically
  valid when a publisher has no donation products yet.
- Subscription product type IDs are looked up via term_relationships /
  term_taxonomy at query time (subscription_product_ids_sql helper);
  the metric-layer cache amortizes the lookup across calls.
- Date params bound via $wpdb->prepare with %s; product-ID lists
  interpolated after intval cast to prevent SQL injection.
- Several PHPCS direct-DB-query phpcs:disable comments at the top —
  this is an analytics layer that explicitly wants direct SQL, not
  the WP query API.

Approximations called out:
- performance_by_product.lifetime_revenue sums renewal-amount rows
  (the subscription parent's total_amount), not historical orders.
  True LTV waits on the v1.1 BQ wrapper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements Storage_Interface against the pre-HPOS WooCommerce order
storage: {prefix}posts (typed by post_type) and {prefix}postmeta.
Mirrors HPOS_Storage method-by-method.

Per-row translation per the schema doc:

  HPOS                          Legacy
  wc_orders.id                  posts.ID
  wc_orders.type                posts.post_type
  wc_orders.status              posts.post_status
  wc_orders.date_created_gmt    posts.post_date_gmt
  wc_orders.customer_id         postmeta._customer_user
  wc_orders.total_amount        postmeta._order_total (DECIMAL string)
  wc_orders.parent_order_id     posts.post_parent
  wc_orders_meta.*              postmeta.*

The product lookup table {prefix}wc_order_product_lookup is populated
by Woo Analytics regardless of backend, so it joins identically here.

Same compat constraints as HPOS_Storage: no CTEs (rewritten as inline
subqueries), donation IDs injected at construction, empty input coerces
to (0) for valid NOT IN syntax.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
)

Wraps the canonical \Newspack\Donations::is_donation_product() with an
aggressive cache so Tab 6 SQL queries can thread a precomputed
:donation_product_ids parameter into NOT IN filters without re-running
the per-product detection logic.

get_donation_product_ids() returns the union of all three detection
paths from the schema doc:

- Path 3 (universal): canonical Newspack donation family — grouped
  parent from the newspack_donation_product_id option plus the three
  children (once/month/year)
- Path 1 (new, v6.41.0): products manually flagged via
  _newspack_is_donation postmeta — Donations::get_flagged_donation_
  product_ids()
- Path 2 (variation expansion): all product_variation post IDs whose
  parent is in the union of Paths 1+3. Necessary because the order
  product lookup table records the variation's product_id, not the
  parent's — a NOT IN filter using only parents would leak variation
  orders through.

is_donation_product( $product_id ) tests against the cached set; safe
for hot loops.

flush_cache() is the hook point for the future NPPD-1605 cache
invalidation layer and for manual recompute after configuration
changes (newspack_donation_product_id option, _newspack_is_donation
flag flips).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Thin dispatch + caching layer over the per-backend storage classes.
Picks HPOS_Storage or Legacy_Storage via Storage_Detector::detect(),
threads the precomputed donation product ID set from
Donation_Product_Classifier::get_donation_product_ids() into the
storage constructor, and wraps each metric call in a transient cache
keyed by `prefix:backend:method:md5(params_json)`.

Cache tiers:

- 30 min (TTL_DEFAULT): windowed metrics and top-line snapshots —
  revenue gross/net, refund rate, new/churned counts, MRR, ARR,
  active count, upcoming renewals, retry rate
- 60 min (TTL_HEAVY): heavy aggregation queries — tenure
  distribution, performance by product, cancellation reasons

Comparison-mode is not implemented here: the REST layer calls these
methods twice (current + prior window) and the cache makes the second
call free if the prior window has already been requested.

get_classification_metadata() exposes backend + donation_product_count
+ has_donation_family for the React classification banner.

flush_all() is the hook point for NPPD-1605 invalidation and for
manual recompute after corrections; not wired to any automatic trigger
today because metrics expire on their own TTL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Subscribers_REST_Controller registering GET on the dedicated
namespace newspack-insights/v1 (separate from newspack/v1 which is
reserved for wizard infrastructure). Single route, single endpoint:

  GET /newspack-insights/v1/subscribers
    ?start=YYYY-MM-DD
    &end=YYYY-MM-DD
    [&compare_start=YYYY-MM-DD&compare_end=YYYY-MM-DD]

Response shape:
  - classification: { backend, donation_product_count, has_donation_family }
  - snapshot:       active_subscribers, mrr, arr, tenure_distribution,
                    upcoming_renewals_30d (window-independent)
  - current:        window + 7 windowed metrics for the requested range
  - previous:       same shape as current, or null when compare_*
                    params are omitted

Date inputs are Y-m-d in the site timezone; start resolves to 00:00:00
and end to 23:59:59 inclusive. Validation rejects malformed dates,
mismatched comparison-pair, and inverted windows with 400 errors.

The Insights_Section_Subscribers stub is expanded to:
  - load_dependencies(): include_once the 7 Tab 6 PHP files in order
    (interface -> detector -> storage backends -> classifier ->
    orchestrator -> REST controller)
  - register_hooks(): add_action('rest_api_init') to register the
    controller's routes

Permission: manage_options, mirroring the wizard capability so the
data layer is only available to users who can view the tab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Squiz.Commenting.FunctionComment.MissingParamTag does not follow
{@inheritdoc} for @param resolution, so each windowed storage override
needs explicit @param tags even though the interface already documents
them. Added @param/@return to the 8 windowed methods in each of
HPOS_Storage and Legacy_Storage. No behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Donation_Product_Classifier::compute_donation_product_ids() was
calling \Newspack\Donations::get_parent_donation_product(), which is
private. Read the option directly via
Donations::DONATION_PRODUCT_ID_OPTION (a public const exposing
'newspack_donation_product_id') instead — same source of truth, no
private-API coupling.

Smoketest confirms both single-window and comparison-mode requests
return the full classification/snapshot/current/previous payload with
donation_product_count = 4 on a configured local site (grouped parent
+ once/month/year children).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
api/subscribers.ts:
- Source-of-truth TypeScript types mirroring the PHP response shape:
  SubscribersResponse { classification, snapshot, current, previous },
  with detail types for tenure rows, performance rows, and
  cancellation reason rows.
- fetchSubscribersData(query) builds the URL and dispatches via
  @wordpress/api-fetch. Comparison params are included only when both
  compare_start and compare_end are provided.

hooks/useSubscribersData.ts:
- Owns Tab 6 fetch lifecycle. Refetches whenever range or
  previousRange changes. Request-id guard prevents older slow calls
  from overwriting newer ones on rapid range switches.
- Exposes idle / loading / success / error state plus a manual
  refetch() for future force-refresh UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ClassificationBanner: surfaces backend (HPOS vs legacy) +
donation classification (excluded product count, or a muted warning
when no donation family is configured). Renders at the top of the tab
so publishers can verify Insights is reading the right slice.

format.ts: Intl.NumberFormat helpers for number / currency (USD, v1) /
percent / signed-percent delta. formatDelta() returns null when prior
is zero (no defined ratio).

MetricCard: scorecard atom. Label + big value + optional comparison
delta with a11y label and up/down/flat directional class. Composed by
Scorecard and Revenue sections.

ScorecardSection: 6 cards — three snapshots (active subs, MRR, ARR),
two windowed-with-delta (new, churned), one snapshot (upcoming
renewals 30d count).

RevenueSection: 4 cards — gross / net revenue, refund rate, failed
payment retry rate. All windowed with delta vs previous window when
comparison mode is on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TenureSection: computes box-plot stats (p25 / median / p75) and
day-bucket counts client-side from the raw per-active-sub
distribution returned by the server. Renders summary stats + a
horizontal bar list for buckets (0-30, 31-90, 91-180, 181-365, 365+).

PerformanceSection: top-50 products by active subscribers, rendered
as a numeric table (active subs, churned subs, active value, lifetime
revenue). Server applies the limit and the descending sort; no
client-side sorting in v1. Lifetime revenue is the documented v1
approximation (sum of renewal-amount rows).

CancellationReasonsSection: bucketed reasons with horizontal bars.
'unknown' is i18n'd; other slugs are humanized (underscores ->
spaces, title case).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the "Coming soon" stub. Calls useSubscribersData on the
active range + comparison range, then composes:
  - ClassificationBanner (top)
  - ScorecardSection (active subs, MRR, ARR, new, churned, upcoming)
  - RevenueSection (gross, net, refund rate, retry rate)
  - TenureSection (box-plot stats + buckets)
  - PerformanceSection (top-50 product table)
  - CancellationReasonsSection (bar list)

Local loading and error states. Wizard chrome (date picker, comparison
toggle, tab nav) stays interactive in all states.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto-formatted by Prettier (line widths, JSX spacing) and added the
two missing /* translators: */ comments above the p25/p75 sprintf
calls in TenureSection. No behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kmwilkerson and others added 8 commits June 3, 2026 22:10
User feedback: centering was the opposite of what was wanted.
Everything in the card now reads from the left edge:

- Body wrapper: align-items center -> flex-start
- Value: text-align center -> left
- Description: text-align center -> left

Label was already left-aligned. The accent line, vertical anchoring
(hero at top, description at bottom), and min-height stay as-is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Variable label wrap was shifting hero numbers out of vertical
alignment across cards. "ACTIVE SUBSCRIBERS" fit on one line, but
"MONTHLY RECURRING REVENUE" wrapped to two, pushing the hero number
~17px lower than its neighbors.

Fix: set explicit line-height (1.4) on the label and reserve a
min-height of 2 × line-height. Single-line labels now occupy the
same vertical space as two-line labels, so hero numbers line up
horizontally across the row regardless of label length.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…easons

- Hero value font-weight 600 -> 500. The 600 felt too heavy for the
  44px size.
- Description gets a 16px margin-top so it's guaranteed to sit a
  comfortable distance below the hero, even when the body region
  flexes tight.
- Remove the Cancellation Reasons section from the UI. Publisher
  data on cancellation reasons is sparse (most cancellations bucket
  as "unknown"), so the section wasn't pulling its weight. Deleted
  the React component and its render call from SubscribersTab.

The storage layer's get_cancellation_reasons method and the REST
response's `cancellation_reasons` field stay in place — cheap to
keep, surfaces if a future tab wants the same data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The histogram duplicated the information already shown in the
percentile callouts above it. Removing it simplifies the section
and removes the visual competition for attention.

The backend storage method get_subscription_tenure_distribution()
is preserved for potential v1.1 tenure visualization revival.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kmwilkerson kmwilkerson changed the title Insights: Tab 6 — Subscribers (NPPD-1616) feat(insights): subscribers tab (NPPD-1616) Jun 4, 2026
kmwilkerson and others added 6 commits June 3, 2026 23:00
get_performance_by_product() in both storage classes accepted
DateTimeInterface $start/$end parameters but the SQL never used them.
The orchestrator's cache key included the window, so every distinct
date range allocated a new transient holding identical data.

Per code review, the four columns have different temporal scopes:

  active_subs       — current state (correctly window-independent)
  active_value      — current state (correctly window-independent)
  lifetime_revenue  — lifetime sum, intentionally not windowed; true
                      LTV waits on the v1.1 BQ wrapper
  churned_subs      — SHOULD be windowed; this commit fixes the bug

Added a LEFT JOIN to the `_schedule_cancelled` meta (wc_orders_meta
on HPOS, postmeta on legacy) and wrapped the churned-count CASE with
a `sch.meta_value BETWEEN %s AND %s` predicate. Active subscriptions
don't have this meta, so the left-joined row is NULL and the CASE
naturally rejects them. Woo writes at most one _schedule_cancelled
row per subscription, so no row multiplication. Window dates pass
through $wpdb->prepare.

Column scope is now documented at the top of each query body.

Verified end-to-end against local test data:
  6-month window:  Captain 7 churned, Boss 4, Ambassador 3
  1-month window:  Captain 1 churned, Boss 0, Ambassador 0
  active_subs and lifetime_revenue identical across both windows

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous CASE only covered a small set of explicit period×interval
combos and fell through to `total_amount` for unknown configurations.
That fallthrough was the opposite of conservative — a biennial
subscription (year × 2) was recorded at the full annual amount as if
it were a monthly contribution, inflating MRR by 24x.

New CASE covers all documented Woo billing periods at any positive
integer interval N:

  day   × N -> total *  30      / N    (30-day month)
  week  × N -> total * (52/12)  / N    (4.333 weeks per month)
  month × N -> total            / N
  year  × N -> total / (12 * N)

The ELSE branch is now truly conservative: falls through to
`total / 12`, which undercounts any non-yearly mis-configuration
rather than inflating it.

Added a diagnostic query that counts active non-donation
subscriptions whose `_billing_period` is not in
('day','week','month','year') OR whose `_billing_interval` casts to 0.
If any exist, logs via Newspack\Logger ('NEWSPACK-INSIGHTS' header) so
the publisher can correct product configuration. The diagnostic
benefits from the same orchestrator-level cache as MRR itself, so the
extra query only runs once per cache window.

Applied to both HPOS_Storage and Legacy_Storage. Verified end-to-end:
local test data is all (month × 1) + (year × 1), so MRR remains
$738.33 — same as before but now mathematically defensible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Donation_Product_Classifier had a flush_cache() method but nothing
in the codebase invoked it on relevant Woo configuration changes.
Publishers reconfiguring donation products would see stale Tab 6 data
for up to one hour while waiting for the 1h TTL.

Added Donation_Product_Classifier::register_hooks() wiring:
  - update_option_newspack_donation_product_id -> flush_cache
  - added_post_meta / updated_post_meta / deleted_post_meta on the
    _newspack_is_donation flag -> flush_cache

The post_meta hooks fire site-wide so the callback filters by
meta_key and early-returns on mismatches.

Insights_Section_Subscribers::register_hooks() now calls
Donation_Product_Classifier::register_hooks() during the tab boot.

Verified end-to-end: all three meta hooks fire with correct key
filtering on real product meta changes (added/updated/deleted of
_newspack_is_donation triggers flush; unrelated keys are skipped).
Option-change hook also fires correctly. The local dev env's object
cache backend has a known delete bug (sets and deletes silently
no-op while values persist in memory), but the callbacks themselves
are invoked correctly and the delete_transient() calls will work
normally in production with a healthy memcached / redis backend.

Also rewrote the MRR comment as a /* */ block with a localized
phpcs:disable so the prose describing the billing math doesn't keep
triggering Squiz.PHP.CommentedOutCode.Found heuristics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nce table

Variable subscription products (the standard monthly/annual variants of
a single membership tier) previously lost their breakdown. The
Performance by product table grouped at the line-item product level,
which in Woo's data model is the parent ID for variations — so a publisher
with Captain Monthly + Captain Annual saw a single Captain row with
silently summed totals.

Both storage classes now query at the resolved-variation level by
COALESCEing `_variation_id` over `_product_id` in the line-item meta
join. Woo writes the PARENT id into `_product_id` and the actual
variation id into `_variation_id` for variable products; the COALESCE
resolves to the variation when present and the standalone product
otherwise. The donation filter continues to read `_product_id` because
the donation set is keyed by the parent in WC's data model.

PHP aggregation reshapes the flat per-variation rows into a parent +
nested variations structure. Each parent entry carries `variations`
sorted by active_subs DESC; standalone products have no `variations`
key. Math reconciles end-to-end:

  Captain: parent 20 active = Annual 12 + Monthly 8
           parent 7 churned = Annual 5 + Monthly 2
           parent $1296 active value = Annual $1200 + Monthly $96
           parent $2144 lifetime = Annual $2000 + Monthly $144

Variation labels come from `_subscription_period`: month→Monthly,
year→Annual, week→Weekly, day→Daily. Fallbacks: variation post_title
with parent prefix stripped, then a generic "Variation" string.

The aggregation + label helpers are duplicated across HPOS_Storage and
Legacy_Storage rather than extracted to a trait — they're pure
transformation with no backend-specific logic and Newspack convention
favors duplication over premature abstraction.

Storage_Interface docblock + Subscribers_Metric cache prefix bumped to
v2 (cached shape change).

React:
- PerformanceRow type updated; new PerformanceVariationRow.
- PerformanceSection wraps each parent + its variations in a Fragment
  and renders each variation as a `--variation` row with `gray-600`
  text and a `padding-left: 44px` Product cell so the indent reads at
  a glance. Standalone products render as a single row (no extra rows
  after them).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…phpcs

Three small fixes to the nested performance table:

1. Variation row indent was not applying. The `&-cell--indented`
   modifier class had specificity (0,1,0), losing to the base
   `.table td` rule (0,2,1) — so `padding-left: 44px` never reached
   the cell. Rewrote the indent as `&-row--variation td:first-child`
   (0,2,2), which wins the cascade cleanly. Dropped the now-dead
   `&-cell--indented` className from PerformanceSection too.

2. Variation text was rendering too faded (wp-colors.$gray-600 felt
   like disabled/placeholder text against the gray-900 parents).
   Bumped to wp-colors.$gray-700 — subordinate to parents but still
   clearly part of the same data table.

3. PHPCS CI failure: Squiz inline-comment sniff flagged the bulleted
   prose in the HPOS performance query comment ("Expected 1 space
   before comment text but found 3"). Converted that block from `//`
   lines with indented bullets to a `/* */` block comment so the
   list structure survives PHPCS.

Churned-subs zeros are NOT a regression: verified the test data has
no `_schedule_cancelled` dates in the trailing 30 days (latest dated
cancellation is May 4, 2026). The windowed churn count is correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ss-tab reuse

Per the pre-Tab-7 audit. Two extractions, both mechanical, no
behavior change.

1. MetricCard + format helpers moved from tabs/subscribers/ to a
   new tabs/components/ directory. Both are generic — MetricCard's
   props were already free of any subscriber-specific shape and the
   format helpers (currency / number / percent / delta / tone) are
   pure pass-through utilities every future tab will need.

   Updated import paths:
     ScorecardSection  -> '../components/MetricCard'
     WindowedSection   -> '../components/MetricCard'
     PerformanceSection -> '../components/format'
     MetricCard's internal './format' import stays relative (same
       new directory).

2. tabs/subscribers/subscribers.scss was ~80% generic Insights chrome
   despite living in a tab-scoped file. Split into:

     tabs/components/sections.scss
       - __tab-loading, __tab-error, __tab-error-detail
       - __section, __section-heading, __section-caption,
         __section-empty
       - __metric-grid
       - __metric-card and all its sub-rules (-label, -body,
         -value, -delta with tones, -description)
       - __table-wrap and __table (incl. -num, the
         __table-row--variation + td:first-child indent pattern)

     tabs/subscribers/subscribers.scss (now slim, Tab 6 only)
       - __subscribers-tab orchestrator wrapper
       - __tenure-card (the percentile callouts container)
       - __stats-summary (the dl layout inside the tenure card)
       - __tenure-narrative

   style.scss now @use's the shared sections.scss so the chrome
   ships once with the main wizard bundle (insights-wizard.css)
   instead of inside the lazy-loaded subscribers chunk. Verified:
   metric-card / section / table / variation-row rules land in
   insights-wizard.css; tenure-card lands in the subscribers chunk
   (3352.css).

Tab 7 will inherit the chrome without importing it and only ship
its own tab-specific styles in its own lazy chunk.

Verified: build green, lint clean, REST endpoint unchanged
(active=35, mrr=738, performance rows=4 with the same nested
variation structure).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Squiz.Commenting.BlockComment.NoEmptyLineBefore fires when a `/*`
block comment follows a `//` line-comment block without an empty
line separating them. CI's PHPCS catches it; the local pass missed
it because the same file was downstream of another fixed instance
on the Tab 7 branch and didn't reproduce there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kmwilkerson kmwilkerson marked this pull request as ready for review June 8, 2026 16:49
Copilot AI review requested due to automatic review settings June 8, 2026 16:49
@kmwilkerson kmwilkerson requested a review from a team as a code owner June 8, 2026 16:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements the first fully data-backed Newspack Insights tab (“Subscribers”), including a WooCommerce-backed PHP data layer (HPOS + legacy), a single REST endpoint to serve the full tab payload, and a React UI that renders snapshot + windowed metrics with optional comparison mode.

Changes:

  • Adds Tab 6 (Subscribers) end-to-end: storage abstraction + metric orchestrator + GET /newspack-insights/v1/subscribers REST endpoint.
  • Adds the Insights wizard React chrome (tabs, date range picker, comparison toggle, shared “metric card”/table styling) plus stub components for the other tabs.
  • Adds Subscribers UI sections (at-a-glance scorecards, windowed metrics, tenure summary, and product performance table) and a client data-fetch hook.

Reviewed changes

Copilot reviewed 47 out of 47 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
plugins/newspack-plugin/src/wizards/insights/tabs/SubscribersTab.tsx Tab 6 orchestrator: fetch + loading/error states + section composition.
plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/WindowedSection.tsx Window-scoped metric cards with dynamic heading based on preset/custom range.
plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/TenureSection.tsx Client-side tenure percentile computation + narrative copy.
plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/subscribers.scss Tab 6-specific layout styles (tenure card, spacing).
plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/ScorecardSection.tsx Snapshot (non-windowed) scorecards: active subs, MRR/ARR, upcoming renewals.
plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/PerformanceSection.tsx Performance-by-product table rendering including variation nesting.
plugins/newspack-plugin/src/wizards/insights/tabs/PromptsTab.tsx Stub tab UI (“Coming soon”).
plugins/newspack-plugin/src/wizards/insights/tabs/GatesTab.tsx Stub tab UI (“Coming soon”).
plugins/newspack-plugin/src/wizards/insights/tabs/EngagementTab.tsx Stub tab UI (“Coming soon”).
plugins/newspack-plugin/src/wizards/insights/tabs/DonorsTab.tsx Stub tab UI (“Coming soon”).
plugins/newspack-plugin/src/wizards/insights/tabs/ConversionTab.tsx Stub tab UI (“Coming soon”).
plugins/newspack-plugin/src/wizards/insights/tabs/AudienceTab.tsx Stub tab UI (“Coming soon”).
plugins/newspack-plugin/src/wizards/insights/tabs/AdvertisingTab.tsx Stub tab UI (“Coming soon”).
plugins/newspack-plugin/src/wizards/insights/tabs/components/sections.scss Shared cross-tab section/card/table/loading-error styling.
plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx Metric card atom with optional delta rendering + tone semantics.
plugins/newspack-plugin/src/wizards/insights/tabs/components/format.ts Intl formatting helpers for numbers/currency/percent/deltas.
plugins/newspack-plugin/src/wizards/insights/style.scss Insights page-level chrome styling + forwards shared tab chrome styles.
plugins/newspack-plugin/src/wizards/insights/state/useDateRange.ts URL-persisted date-range state + preset range computation.
plugins/newspack-plugin/src/wizards/insights/state/useComparisonMode.ts URL-persisted compare toggle + previous-window computation.
plugins/newspack-plugin/src/wizards/insights/index.tsx Insights wizard entrypoint + fallback boot config.
plugins/newspack-plugin/src/wizards/insights/hooks/useSubscribersData.ts Data-fetch lifecycle hook for Subscribers REST endpoint.
plugins/newspack-plugin/src/wizards/insights/components/TabNavigation.tsx Tab bar UI + visibility filtering.
plugins/newspack-plugin/src/wizards/insights/components/TabContent.tsx Lazy-loaded tab panel switcher with Suspense fallback.
plugins/newspack-plugin/src/wizards/insights/components/LastUpdated.tsx “Updated X ago” header timestamp display.
plugins/newspack-plugin/src/wizards/insights/components/InsightsWizard.tsx Top-level Insights wizard chrome (tabs/range/compare routing).
plugins/newspack-plugin/src/wizards/insights/components/DateRangePicker.tsx Preset/custom date range picker component.
plugins/newspack-plugin/src/wizards/insights/components/ComparisonToggle.tsx Compare-to-previous-period checkbox component.
plugins/newspack-plugin/src/wizards/insights/api/subscribers.ts Typed API client for /newspack-insights/v1/subscribers.
plugins/newspack-plugin/src/wizards/index.tsx Registers the Insights wizard entry in the wizards map.
plugins/newspack-plugin/includes/wizards/insights/storage/class-storage-interface.php Storage contract for Tab 6 queries.
plugins/newspack-plugin/includes/wizards/insights/storage/class-storage-detector.php Detects HPOS vs legacy storage (cached).
plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-storage.php Legacy CPT SQL implementation of Tab 6 storage contract.
plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-storage.php HPOS SQL implementation of Tab 6 storage contract.
plugins/newspack-plugin/includes/wizards/insights/metrics/class-subscribers-metric.php Metric orchestrator + transient caching + backend dispatch.
plugins/newspack-plugin/includes/wizards/insights/classifiers/class-donation-product-classifier.php Cached donation product ID classifier + invalidation hooks.
plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php PHP wizard registration + boot config localization + feature flag gate.
plugins/newspack-plugin/includes/wizards/insights/class-insights-section-subscribers.php Subscribers section boot: loads dependencies + registers REST route + classifier hooks.
plugins/newspack-plugin/includes/wizards/insights/class-insights-section-prompts.php Prompts section stub init/hook point.
plugins/newspack-plugin/includes/wizards/insights/class-insights-section-gates.php Gates section stub init/hook point.
plugins/newspack-plugin/includes/wizards/insights/class-insights-section-engagement.php Engagement section stub init/hook point.
plugins/newspack-plugin/includes/wizards/insights/class-insights-section-donors.php Donors section stub init/hook point.
plugins/newspack-plugin/includes/wizards/insights/class-insights-section-conversion.php Conversion Journey section stub init/hook point.
plugins/newspack-plugin/includes/wizards/insights/class-insights-section-audience.php Audience section stub init/hook point.
plugins/newspack-plugin/includes/wizards/insights/class-insights-section-advertising.php Advertising section stub init/hook point.
plugins/newspack-plugin/includes/wizards/insights/api/class-subscribers-rest-controller.php REST controller for /newspack-insights/v1/subscribers.
plugins/newspack-plugin/includes/class-wizards.php Registers Insights wizard + initializes Insights section classes.
plugins/newspack-plugin/includes/class-newspack.php Includes new Insights wizard/section PHP files at bootstrap.

@kmwilkerson kmwilkerson merged commit c953c37 into main Jun 9, 2026
7 checks passed
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown

Hey @kmwilkerson, good job getting this PR merged! 🎉

Now, the needs-changelog label has been added to it.

Please check if this PR needs to be included in the "Upcoming Changes" and "Release Notes" doc. If it doesn't, simply remove the label.

If it does, please add an entry to our shared document, with screenshots and testing instructions if applicable, then remove the label.

Thank you! ❤️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants